Generalized linear models: interactive

PLS 206 — Applied Multivariate Modeling

Apps for the GLM families (the Week 3 bonus deck: slides, PDF; the code). They run in the browser, so no R installation is needed; the first one takes a few seconds to load. Tick “Show R code” in any app to get code that reproduces the current view in RStudio.

Cheat sheet

Every GLM is three choices: a distribution for \(y\) (how it scatters around its mean), a linear predictor \(\eta = \beta_0 + \beta_1 x_1 + \dots\) (the same straight line as lm()), and a link \(g(\mu) = \eta\) that connects the mean of \(y\) to the line.

\(y\) looks like family link \(g(\mu)\) inverse link \(\mu = g^{-1}(\eta)\) spread \(\text{Var}(y)\) in R \(e^{\beta_1}\)
any number, symmetric Normal \(\mu\) \(\eta\) \(\sigma^2\) lm(y ~ x) (\(\beta_1\) adds to the mean)
0 / 1 Binomial \(\log\frac{p}{1-p}\) plogis(eta) \(p(1-p)\) glm(y ~ x, family = binomial) odds ratio
\(k\) of \(n\) Binomial \(\log\frac{p}{1-p}\) plogis(eta) \(n\,p(1-p)\) glm(cbind(k, n - k) ~ x, family = binomial) odds ratio
counts Poisson \(\log\mu\) exp(eta) \(\mu\) glm(y ~ x, family = poisson) rate ratio
clumped counts negative binomial \(\log\mu\) exp(eta) \(\mu + \mu^2/\theta\) MASS::glm.nb(y ~ x) rate ratio
counts per effort Poisson or NB + offset \(\log\mu\) effort * exp(eta) as above glm(y ~ x + offset(log(effort)), family = poisson) rate ratio per unit effort
positive, right-skewed Gamma \(\log\mu\) exp(eta) \(\mu^2/k\) glm(y ~ x, family = Gamma(link = "log")) ratio of means

Which family? Start from what kind of number \(y\) is. For counts, fit a Poisson and check deviance(m) / df.residual(m): well above 1 means switch to the negative binomial. If samples differ in effort (leaves scored, trap-days, quadrat area), add the offset.


The GLM family explorer

Pick a family. The left panel is the link scale, where every GLM is a straight line, \(\eta = \beta_0 + \beta_1 x\). The right panel is the data scale, where the inverse link bends that line into a mean the data can actually have. Move \(\beta_0\) and \(\beta_1\) and watch the line and the curve move together. Then tick “Also fit lm()” to see what a straight line on the raw data does instead.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 980

library(shiny)

fams <- c("Normal (identity link)" = "gaussian",
          "Binomial, 0/1 (logit link)" = "bin01",
          "Binomial, k of n (logit link)" = "bink",
          "Poisson (log link)" = "poisson",
          "Negative binomial (log link)" = "negbin",
          "Poisson + offset (log link)" = "offset",
          "Gamma (log link)" = "gamma")
defaults <- list(
  gaussian = list(b0 = c(-5, 10, 2), b1 = c(-2, 2, 0.5)),
  bin01    = list(b0 = c(-6, 6, -3), b1 = c(-1.5, 1.5, 0.6)),
  bink     = list(b0 = c(-6, 6, -3), b1 = c(-1.5, 1.5, 0.6)),
  poisson  = list(b0 = c(-2, 4, 0.5), b1 = c(-0.5, 0.5, 0.2)),
  negbin   = list(b0 = c(-2, 4, 0.5), b1 = c(-0.5, 0.5, 0.2)),
  offset   = list(b0 = c(-4, 2, -1.5), b1 = c(-0.5, 0.5, 0.1)),
  gamma    = list(b0 = c(-2, 5, 1), b1 = c(-0.5, 0.5, 0.2)))

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(
      selectInput("fam", "Family", fams, selected = "poisson"),
      sliderInput("b0", "Intercept (β0)", -2, 4, 0.5, 0.1),
      sliderInput("b1", "Slope (β1)", -0.5, 0.5, 0.2, 0.01),
      sliderInput("n", "Observations (n)", 20, 500, 100, 10),
      conditionalPanel("input.fam == 'gaussian'", sliderInput("sigma", "Spread (σ)", 0.2, 5, 1.5, 0.1)),
      conditionalPanel("input.fam == 'bink'", sliderInput("trials", "Most trials per row (n_i up to)", 5, 200, 50, 5)),
      conditionalPanel("input.fam == 'negbin'", sliderInput("theta", "Clumping θ (smaller = more spread)", 0.3, 50, 2, 0.1)),
      conditionalPanel("input.fam == 'gamma'", sliderInput("shape", "Gamma shape k (bigger = less spread)", 0.5, 20, 2, 0.5)),
      conditionalPanel("input.fam == 'offset'",
        checkboxInput("effort_x", "Effort rises with x", TRUE),
        checkboxInput("no_offset", "Also fit without the offset", TRUE)),
      checkboxInput("show_lm", "Also fit lm() (straight line on the raw data)", FALSE),
      actionButton("resample", "New sample"),
      hr(),
      checkboxInput("show_code", "Show R code", FALSE)
    ),
    mainPanel(
      plotOutput("plot", height = "430px"),
      verbatimTextOutput("eq"),
      verbatimTextOutput("stats"),
      conditionalPanel("input.show_code == true", verbatimTextOutput("code_out"))
    )
  )
)

server <- function(input, output, session) {
  seed <- reactiveVal(206)
  observeEvent(input$resample, seed(seed() + 1), ignoreInit = TRUE)
  observeEvent(input$fam, {
    d <- defaults[[input$fam]]
    updateSliderInput(session, "b0", min = d$b0[1], max = d$b0[2], value = d$b0[3])
    updateSliderInput(session, "b1", min = d$b1[1], max = d$b1[2], value = d$b1[3])
  }, ignoreInit = TRUE)

  calc_code <- reactive({
    f <- input$fam
    head <- paste0('set.seed(', seed(), ')\nn <- ', input$n, '; b0 <- ', input$b0, '; b1 <- ', input$b1, '\n',
                   'x <- runif(n, 0, 10)\neta <- b0 + b1 * x                 # 2. the linear predictor\n')
    body <- switch(f,
      gaussian = paste0('mu  <- eta                          # 3. identity link: the mean IS the line\n',
                        'y   <- rnorm(n, mu, ', input$sigma, ')             # 1. Normal: constant spread\n',
                        'dat <- data.frame(x, y)\n',
                        'model <- glm(y ~ x, family = gaussian, data = dat)   # same as lm(y ~ x)\n'),
      bin01 = paste0('mu  <- plogis(eta)                  # 3. logit link: probability = plogis(line)\n',
                     'y   <- rbinom(n, 1, mu)             # 1. Bernoulli: each row is 0 or 1\n',
                     'dat <- data.frame(x, y)\n',
                     'model <- glm(y ~ x, family = binomial, data = dat)\n'),
      bink = paste0('mu  <- plogis(eta)                  # 3. logit link\n',
                    'trials <- sample(5:', input$trials, ', n, replace = TRUE)   # n_i differs by row\n',
                    'y   <- rbinom(n, trials, mu)        # 1. Binomial: y successes of n_i\n',
                    'dat <- data.frame(x, y, trials)\n',
                    'model <- glm(cbind(y, trials - y) ~ x, family = binomial, data = dat)\n'),
      poisson = paste0('mu  <- exp(eta)                     # 3. log link: mean = exp(line)\n',
                       'y   <- rpois(n, mu)                 # 1. Poisson: variance = mean\n',
                       'dat <- data.frame(x, y)\n',
                       'model <- glm(y ~ x, family = poisson, data = dat)\n'),
      negbin = paste0('mu  <- exp(eta)                     # 3. log link\n',
                      'y   <- rnbinom(n, mu = mu, size = ', input$theta, ')   # 1. NB: variance = mu + mu^2/theta\n',
                      'dat <- data.frame(x, y)\n',
                      'pois <- glm(y ~ x, family = poisson, data = dat)   # for comparison\n',
                      '# negative binomial by maximum likelihood; glm.nb() in the MASS package gives the same estimates\n',
                      'nll <- function(p) -sum(dnbinom(y, mu = exp(p[1] + p[2] * x), size = exp(p[3]), log = TRUE))\n',
                      'fit <- optim(c(coef(pois), 0), nll, method = "BFGS", hessian = TRUE)\n',
                      'theta <- exp(fit$par[3])\n',
                      'est <- setNames(fit$par[1:2], c("(Intercept)", "x"))\n',
                      'se  <- sqrt(diag(solve(fit$hessian)))[1:2]\n',
                      'tab <- cbind(Estimate = est, `Std. Error` = se, `z value` = est / se,\n',
                      '             `Pr(>|z|)` = 2 * pnorm(-abs(est / se)))\n'),
      offset = paste0(if (isTRUE(input$effort_x)) 'effort <- pmax(1, round(2 + 1.8 * x + runif(n, -2, 2)))   # more effort at high x\n'
                      else 'effort <- round(runif(n, 1, 20))   # effort unrelated to x\n',
                      'rate <- exp(eta)                    # 3. log link: rate per unit effort\n',
                      'mu  <- effort * rate                # expected count = effort x rate\n',
                      'y   <- rpois(n, mu)                 # 1. Poisson\n',
                      'dat <- data.frame(x, y, effort)\n',
                      'model <- glm(y ~ x + offset(log(effort)), family = poisson, data = dat)\n',
                      if (isTRUE(input$no_offset)) 'model_no <- glm(y ~ x, family = poisson, data = dat)   # offset forgotten\n' else ''),
      gamma = paste0('mu  <- exp(eta)                     # 3. log link\n',
                     'y   <- rgamma(n, shape = ', input$shape, ', rate = ', input$shape, ' / mu)   # 1. Gamma: SD grows with mean\n',
                     'dat <- data.frame(x, y)\n',
                     'model <- glm(y ~ x, family = Gamma(link = "log"), data = dat)\n'))
    tail <- paste0(if (f != "negbin") 'est <- coef(model)\ntab <- coef(summary(model))\n' else '',
                   'tab\n',
                   if (f != "gaussian") 'exp(est)   # back on the data scale: ratios\n' else '',
                   if (isTRUE(input$show_lm)) paste0('ols <- lm(', if (f == "bink") 'y / trials' else if (f == "offset") 'y / effort' else 'y', ' ~ x, data = dat)   # a straight line on the raw data\n') else '')
    paste0(head, body, tail)
  })

  plot_code <- reactive({
    f <- input$fam
    yresp <- switch(f, bink = "dat$y / dat$trials", offset = "dat$y / dat$effort", "dat$y")
    ylab_r <- switch(f, gaussian = "y", bin01 = "y (0/1) and P(y = 1)", bink = "y / trials", offset = "y / effort (rate)",
                     poisson = "count y", negbin = "count y", gamma = "y")
    ylink <- switch(f,
      gaussian = 'dat$y',
      bin01 = 'NULL',
      bink = 'qlogis((dat$y + 0.5) / (dat$trials + 1))',
      offset = 'log((dat$y + 0.5) / dat$effort)',
      gamma = 'log(dat$y)',
      'log(dat$y + 0.5)')
    ylab_l <- switch(f, gaussian = "eta = mean of y", bin01 = "eta = log-odds", bink = "eta = log-odds",
                     offset = "eta = log(rate)", gamma = "eta = log(mean)", "eta = log(mean)")
    linkinv <- switch(f, gaussian = "identity", bin01 = "plogis", bink = "plogis", "exp")
    paste0(
'grid <- data.frame(x = seq(0, 10, length.out = 200), effort = 1)
fit_eta <- est[1] + est[2] * grid$x       # the fitted line
true_eta <- b0 + b1 * grid$x
par(mfrow = c(1, 2))

# left: the link scale, where the model is a straight line
yl <- ', ylink, '
', if (f == "bin01") '# 0/1 data have no logit of their own: bin x and take the log-odds of each bin
bins <- cut(dat$x, seq(0, 10, 1), include.lowest = TRUE)
k <- tapply(dat$y, bins, sum); m <- tapply(dat$y, bins, length)
bx <- tapply(dat$x, bins, mean); yl <- log((k + 0.5) / (m - k + 0.5))
' else '', 'plot(', if (f == "bin01") 'bx' else 'dat$x', ', yl, pch = 16, col = adjustcolor("steelblue", 0.5),
     xlim = c(0, 10), ylim = range(yl, true_eta, fit_eta, finite = TRUE),
     xlab = "x", ylab = "', ylab_l, '", main = "link scale: a straight line")
lines(grid$x, true_eta, col = "#00A060", lwd = 3)
lines(grid$x, fit_eta, lty = 2, lwd = 2)

# right: the data scale, where the inverse link bends the line
yr <- ', if (f == "bin01") 'jitter(dat$y, amount = 0.03)' else yresp, '
plot(dat$x, yr, pch = 16, col = adjustcolor("steelblue", 0.5),
     ylim = range(yr, ', linkinv, '(true_eta)', if (isTRUE(input$show_lm)) ', predict(ols, grid)' else '', '),
     xlab = "x", ylab = "', ylab_r, '", main = "data scale: ', linkinv, '(line)")
lines(grid$x, ', linkinv, '(true_eta), col = "#E0524A", lwd = 3)
lines(grid$x, ', linkinv, '(fit_eta), lty = 2, lwd = 2)
', if (f == "offset" && isTRUE(input$no_offset)) 'lines(grid$x, predict(model_no, grid, type = "response") / mean(dat$effort), col = "purple", lwd = 2, lty = 3)
' else '',
if (isTRUE(input$show_lm)) 'abline(ols, col = "grey30", lwd = 2, lty = 4)
' else '',
'legend("topleft", bty = "n", cex = 0.85,
       legend = c("truth", "glm fit"', if (f == "offset" && isTRUE(input$no_offset)) ', "no offset (per average effort)"' else '',
       if (isTRUE(input$show_lm)) ', "lm() fit"' else '', '),
       col = c("#E0524A", "black"', if (f == "offset" && isTRUE(input$no_offset)) ', "purple"' else '',
       if (isTRUE(input$show_lm)) ', "grey30"' else '', '), lty = c(1, 2', if (f == "offset" && isTRUE(input$no_offset)) ', 3' else '',
       if (isTRUE(input$show_lm)) ', 4' else '', '), lwd = 2)
')
  })

  res <- reactive({
    env <- new.env()
    eval(parse(text = calc_code()), envir = env)
    env
  })

  output$plot <- renderPlot({
    env <- res()
    par(mar = c(4.5, 4.5, 2.5, 1))
    eval(parse(text = plot_code()), envir = env)
  })

  output$eq <- renderText({
    f <- input$fam
    lp <- sprintf("%.2f + %.2f x", input$b0, input$b1)
    switch(f,
      gaussian = sprintf("1. distribution:  y ~ Normal(mu, sigma = %s)\n2. line:          eta = %s\n3. link:          mu = eta  (identity)\n   spread:        Var(y) = sigma^2, the same everywhere", input$sigma, lp),
      bin01 = sprintf("1. distribution:  y ~ Bernoulli(p)\n2. line:          eta = %s\n3. link:          log(p / (1 - p)) = eta,  so  p = plogis(eta)\n   spread:        Var(y) = p(1 - p)", lp),
      bink = sprintf("1. distribution:  y ~ Binomial(n_i, p)\n2. line:          eta = %s\n3. link:          log(p / (1 - p)) = eta,  so  p = plogis(eta)\n   spread:        Var(y) = n_i p(1 - p)", lp),
      poisson = sprintf("1. distribution:  y ~ Poisson(mu)\n2. line:          eta = %s\n3. link:          log(mu) = eta,  so  mu = exp(eta)\n   spread:        Var(y) = mu", lp),
      negbin = sprintf("1. distribution:  y ~ NegBin(mu, theta = %s)\n2. line:          eta = %s\n3. link:          log(mu) = eta,  so  mu = exp(eta)\n   spread:        Var(y) = mu + mu^2 / theta", input$theta, lp),
      offset = sprintf("1. distribution:  y ~ Poisson(mu)\n2. line:          eta = %s\n3. link:          log(mu) = log(effort) + eta,  so  mu / effort = exp(eta)\n   spread:        Var(y) = mu", lp),
      gamma = sprintf("1. distribution:  y ~ Gamma(mean mu, shape k = %s)\n2. line:          eta = %s\n3. link:          log(mu) = eta,  so  mu = exp(eta)\n   spread:        Var(y) = mu^2 / k  (SD grows with the mean)", input$shape, lp))
  })

  output$stats <- renderPrint({
    e <- res(); f <- input$fam
    cf <- e$tab
    cat("Fitted model (truth: b0 =", input$b0, " b1 =", input$b1, ")\n")
    print(signif(cf, 3))
    b1 <- e$est[2]
    cat("\nWhat beta1 means: one unit more x ...\n")
    cat(switch(f,
      gaussian = sprintf("  adds %.3f to the mean of y\n", b1),
      bin01 = , bink = sprintf("  multiplies the odds by exp(%.3f) = %.3f (odds ratio)\n", b1, exp(b1)),
      offset = sprintf("  multiplies the rate per unit effort by exp(%.3f) = %.3f (rate ratio)\n", b1, exp(b1)),
      sprintf("  multiplies the mean by exp(%.3f) = %.3f\n", b1, exp(b1))))
    if (f %in% c("poisson", "negbin", "offset")) {
      pm <- if (f == "negbin") e$pois else e$model
      cat(sprintf("\nPoisson check: deviance / df = %.2f (near 1 if Poisson fits)\n", deviance(pm) / df.residual(pm)))
    }
    if (f == "negbin") cat(sprintf("Estimated theta = %.2f\nSE of the slope: Poisson %.4f, negative binomial %.4f\n",
                                   e$theta, coef(summary(e$pois))[2, 2], cf[2, 2]))
    if (f == "offset" && isTRUE(input$no_offset))
      cat(sprintf("exp(b1) without the offset: %.3f\nexp(b1) with the offset:    %.3f\ntruth:                      %.3f\n",
                  exp(coef(e$model_no)[2]), exp(b1), exp(input$b1)))
    if (isTRUE(input$show_lm)) cat(sprintf("\nlm() slope on the raw data: %.4f\n(a straight line, no link, constant spread)\n", coef(e$ols)[2]))
  })

  output$code_out <- renderText(paste0(calc_code(), "\n", plot_code()))
}

shinyApp(ui, server)

The family is a choice about spread

Two families can share the same mean and the same link and still disagree about the data. Set a mean and compare what each family says one observation could look like. Poisson ties the variance to the mean; the negative binomial adds clumping on top; the Gamma keeps the coefficient of variation fixed; the Normal ignores the mean entirely.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 760

library(shiny)

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(
      sliderInput("mu", "The mean (μ)", 0.5, 50, 5, 0.5),
      sliderInput("sigma", "Normal: σ", 0.5, 20, 3, 0.5),
      sliderInput("theta", "Negative binomial: θ", 0.3, 50, 2, 0.1),
      sliderInput("shape", "Gamma: shape k", 0.5, 20, 2, 0.5),
      hr(),
      checkboxInput("show_code", "Show R code", FALSE),
      helpText("Raise the mean: the Normal keeps the same width, the Poisson widens slowly (SD = √μ), the negative binomial and Gamma widen in proportion to μ.")
    ),
    mainPanel(
      plotOutput("plot", height = "460px"),
      verbatimTextOutput("stats"),
      conditionalPanel("input.show_code == true", verbatimTextOutput("code_out"))
    )
  )
)

server <- function(input, output, session) {
  code <- reactive(paste0(
'mu <- ', input$mu, '
counts <- 0:max(20, ceiling(mu * 4))
x <- seq(0.01, max(20, mu * 4), length.out = 400)
par(mfrow = c(2, 2))
plot(x, dnorm(x, mu, ', input$sigma, '), type = "l", lwd = 2, col = "#0070C0",
     main = "Normal: Var = sigma^2", xlab = "y", ylab = "density"); abline(v = mu, lty = 2)
plot(counts, dpois(counts, mu), type = "h", lwd = 3, col = "#00A060",
     main = "Poisson: Var = mu", xlab = "y", ylab = "probability"); abline(v = mu, lty = 2)
plot(counts, dnbinom(counts, mu = mu, size = ', input$theta, '), type = "h", lwd = 3, col = "#00A060",
     main = "Negative binomial: Var = mu + mu^2/theta", xlab = "y", ylab = "probability"); abline(v = mu, lty = 2)
plot(x, dgamma(x, shape = ', input$shape, ', rate = ', input$shape, ' / mu), type = "l", lwd = 2, col = "#7030A0",
     main = "Gamma: Var = mu^2/k", xlab = "y", ylab = "density"); abline(v = mu, lty = 2)
'))
  output$plot <- renderPlot({ par(mar = c(4.2, 4.2, 2.5, 1)); eval(parse(text = code())) })
  output$stats <- renderPrint({
    mu <- input$mu
    sds <- c(Normal = input$sigma, Poisson = sqrt(mu), `Negative binomial` = sqrt(mu + mu^2 / input$theta),
             Gamma = mu / sqrt(input$shape))
    cat(sprintf("Mean = %.1f in every panel (dashed line). Standard deviation of one observation:\n", mu))
    print(data.frame(SD = round(sds, 2)))
    cat(sprintf("\nP(y = 0):  Poisson %.3f   negative binomial %.3f\n", dpois(0, mu), dnbinom(0, mu = mu, size = input$theta)))
  })
  output$code_out <- renderText(code())
}

shinyApp(ui, server)

Why the offset

Lesions counted on the leaves scored, insects per trap-day, seeds per quadrat: the count depends on how hard you looked. The offset, + offset(log(effort)), models the count per unit effort. Set how strongly effort is tied to x and compare the two fits. When effort is unrelated to x, leaving the offset out only adds noise. When effort rises with x, it biases the slope.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 820

library(shiny)

ui <- fluidPage(
  sidebarLayout(
    sidebarPanel(
      sliderInput("b1", "True effect of x on the rate (β1, log scale)", -0.3, 0.3, 0.05, 0.01),
      sliderInput("tie", "How strongly effort rises with x", 0, 1, 0.8, 0.05),
      sliderInput("n", "Samples (n)", 20, 400, 120, 10),
      actionButton("resample", "New sample"),
      hr(),
      checkboxInput("show_code", "Show R code", FALSE),
      helpText("Set β1 = 0 and the tie to 1: nothing happens to the rate, but the model without the offset finds a strong effect of x.")
    ),
    mainPanel(
      plotOutput("plot", height = "400px"),
      verbatimTextOutput("stats"),
      conditionalPanel("input.show_code == true", verbatimTextOutput("code_out"))
    )
  )
)

server <- function(input, output, session) {
  seed <- reactiveVal(206)
  observeEvent(input$resample, seed(seed() + 1), ignoreInit = TRUE)
  code <- reactive(paste0(
'set.seed(', seed(), ')
n <- ', input$n, '; b1 <- ', input$b1, '; tie <- ', input$tie, '
x <- runif(n, 0, 10)
effort <- round(exp(log(10) + tie * (x - 5) * 0.3 + rnorm(n, 0, 0.4)))   # e.g. leaves scored
effort <- pmax(effort, 1)
rate <- exp(-1 + b1 * x)                       # true count per unit effort
y <- rpois(n, effort * rate)
with_off <- glm(y ~ x + offset(log(effort)), family = poisson)
no_off   <- glm(y ~ x, family = poisson)
coef(summary(with_off))["x", ]
coef(summary(no_off))["x", ]

par(mfrow = c(1, 3))
plot(x, effort, pch = 16, col = "grey40", main = "effort vs x")
plot(x, y, pch = 16, col = adjustcolor("steelblue", 0.6), cex = sqrt(effort / 5), main = "raw counts (size = effort)")
plot(x, y / effort, pch = 16, col = adjustcolor("steelblue", 0.6), main = "count per unit effort", ylab = "y / effort")
g <- data.frame(x = seq(0, 10, 0.1), effort = 1)
lines(g$x, exp(-1 + b1 * g$x), col = "#E0524A", lwd = 3)                              # truth
lines(g$x, predict(with_off, g, type = "response"), lty = 2, lwd = 2)                 # with offset
lines(g$x, predict(no_off, g, type = "response") / mean(effort), col = "purple", lty = 3, lwd = 2)   # without
legend("topleft", bty = "n", c("truth", "with offset", "no offset"), col = c("#E0524A", "black", "purple"), lty = 1:3, lwd = 2)
'))
  parts <- reactive(strsplit(code(), "\npar(mfrow", fixed = TRUE)[[1]])
  res <- reactive({ e <- new.env(); eval(parse(text = parts()[1]), envir = e); e })
  output$plot <- renderPlot({
    e <- res(); par(mar = c(4.2, 4.2, 2.5, 1))
    eval(parse(text = paste0("par(mfrow", parts()[2])), envir = e)
  })
  output$stats <- renderPrint({
    e <- res()
    a <- coef(summary(e$with_off))["x", ]; b <- coef(summary(e$no_off))["x", ]
    cat(sprintf("Correlation of effort with x: %.2f\n\n", cor(e$x, e$effort)))
    cat(sprintf("Truth:        exp(b1) = %.3f per unit x\n", exp(input$b1)))
    cat(sprintf("With offset:  exp(b1) = %.3f   p = %.2g\n", exp(a[1]), a[4]))
    cat(sprintf("No offset:    exp(b1) = %.3f   p = %.2g\n", exp(b[1]), b[4]))
  })
  output$code_out <- renderText(code())
}

shinyApp(ui, server)